Перед нами стартап, который продаёт продукты питания. Нужно разобраться, как ведут себя пользователи мобильного приложения.
Необходимо изучить воронку продаж. Узнать, как пользователи доходят до покупки. Сколько пользователей доходит до покупки, а сколько — «застревает» на предыдущих шагах? На каких именно?
После этого необходимо исследовать результаты A/A/B-эксперимента. Дизайнеры захотели поменять шрифты во всём приложении, а менеджеры испугались, что пользователям будет непривычно. Договорились принять решение по результатам A/A/B-теста. Пользователей разбили на 3 группы: 2 контрольные со старыми шрифтами и одну экспериментальную — с новыми. Необходимо выяснить, какой шрифт лучше.
Описание данных
Каждая запись в логе — это действие пользователя, или событие.
EventName — название события;DeviceIDHash — уникальный идентификатор пользователя;EventTimestamp — время события;ExpId — номер эксперимента: 246 и 247 — контрольные группы, а 248 — экспериментальная.# импортируем библиотеки
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import scipy.stats as stats
import seaborn as sns
import datetime as dt
from datetime import date
import plotly.express as px
# убираем предупреждения
pd.options.mode.chained_assignment = None # default='warn'
from matplotlib.axes._axes import _log as matplotlib_axes_logger
matplotlib_axes_logger.setLevel('ERROR')
# увеличим максимальное количество отображающихся столбцов
pd.set_option('display.max_columns', None)
# увеличим максимальную ширину отображающихся столбцов
pd.set_option('max_colwidth', 120)
data¶# считаем данные
try:
data = pd.read_csv('/datasets/logs_exp.csv', sep='\t')
except:
data = pd.read_csv('logs_exp.csv', sep='\t')
data
| EventName | DeviceIDHash | EventTimestamp | ExpId | |
|---|---|---|---|---|
| 0 | MainScreenAppear | 4575588528974610257 | 1564029816 | 246 |
| 1 | MainScreenAppear | 7416695313311560658 | 1564053102 | 246 |
| 2 | PaymentScreenSuccessful | 3518123091307005509 | 1564054127 | 248 |
| 3 | CartScreenAppear | 3518123091307005509 | 1564054127 | 248 |
| 4 | PaymentScreenSuccessful | 6217807653094995999 | 1564055322 | 248 |
| ... | ... | ... | ... | ... |
| 244121 | MainScreenAppear | 4599628364049201812 | 1565212345 | 247 |
| 244122 | MainScreenAppear | 5849806612437486590 | 1565212439 | 246 |
| 244123 | MainScreenAppear | 5746969938801999050 | 1565212483 | 246 |
| 244124 | MainScreenAppear | 5746969938801999050 | 1565212498 | 246 |
| 244125 | OffersScreenAppear | 5746969938801999050 | 1565212517 | 246 |
244126 rows × 4 columns
.info()¶data.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 244126 entries, 0 to 244125 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 EventName 244126 non-null object 1 DeviceIDHash 244126 non-null int64 2 EventTimestamp 244126 non-null int64 3 ExpId 244126 non-null int64 dtypes: int64(3), object(1) memory usage: 7.5+ MB
Можем заметить, что:
datetimeВсего в датафрейме 244126 наблюдений.
data.columns = ['event_name', 'user_id', 'event_time', 'exp_id']
data.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 244126 entries, 0 to 244125 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 event_name 244126 non-null object 1 user_id 244126 non-null int64 2 event_time 244126 non-null int64 3 exp_id 244126 non-null int64 dtypes: int64(3), object(1) memory usage: 7.5+ MB
event_time в формате datetime¶data['event_time'] = pd.to_datetime(data['event_time'], unit='s')
date¶data['date'] = data['event_time'].dt.date
data[data.duplicated()]
| event_name | user_id | event_time | exp_id | date | |
|---|---|---|---|---|---|
| 453 | MainScreenAppear | 5613408041324010552 | 2019-07-30 08:19:44 | 248 | 2019-07-30 |
| 2350 | CartScreenAppear | 1694940645335807244 | 2019-07-31 21:51:39 | 248 | 2019-07-31 |
| 3573 | MainScreenAppear | 434103746454591587 | 2019-08-01 02:59:37 | 248 | 2019-08-01 |
| 4076 | MainScreenAppear | 3761373764179762633 | 2019-08-01 03:47:46 | 247 | 2019-08-01 |
| 4803 | MainScreenAppear | 2835328739789306622 | 2019-08-01 04:44:01 | 248 | 2019-08-01 |
| ... | ... | ... | ... | ... | ... |
| 242329 | MainScreenAppear | 8870358373313968633 | 2019-08-07 19:26:44 | 247 | 2019-08-07 |
| 242332 | PaymentScreenSuccessful | 4718002964983105693 | 2019-08-07 19:26:45 | 247 | 2019-08-07 |
| 242360 | PaymentScreenSuccessful | 2382591782303281935 | 2019-08-07 19:27:29 | 246 | 2019-08-07 |
| 242362 | CartScreenAppear | 2382591782303281935 | 2019-08-07 19:27:29 | 246 | 2019-08-07 |
| 242635 | MainScreenAppear | 4097782667445790512 | 2019-08-07 19:36:58 | 246 | 2019-08-07 |
413 rows × 5 columns
Выявлено 413 дубликатов, удалим их.
data = data.drop_duplicates().reset_index()
# используем метод groupby и функцию nunique для определения количества групп, в которых участвовал каждый пользователь
data_grouped = data.groupby('user_id').agg({'exp_id': 'nunique'}).sort_values('exp_id', ascending=False).reset_index()
# посчитаем количество пользователей, которые участвовали в двух группах
print('Количество пользователей равно:', len(data_grouped))
print('Количество пользователей, которые участвовали в двух и более группах равно:', len(data_grouped.query('exp_id==2 or exp_id==3')))
Количество пользователей равно: 7551 Количество пользователей, которые участвовали в двух и более группах равно: 0
Вывод
Отсутствуют пользователи, которые участвовали в нескольких экспериментальных группах. Следовательно, разграничение пользователей между группами в эксперименте сконструировано правильно.
print('Уникальные события:', data['event_name'].unique())
print()
print('Количество уникальных событий:', len(data['event_name'].unique()))
Уникальные события: ['MainScreenAppear' 'PaymentScreenSuccessful' 'CartScreenAppear' 'OffersScreenAppear' 'Tutorial'] Количество уникальных событий: 5
print('Уникальные пользователи:', data['user_id'].unique())
print()
print('Количество уникальных пользователей:', len(data['user_id'].unique()))
Уникальные пользователи: [4575588528974610257 7416695313311560658 3518123091307005509 ... 6660805781687343085 7823752606740475984 3454683894921357834] Количество уникальных пользователей: 7551
print('На пользователя в среднем приходится событий:', round(data.groupby('user_id').count()['event_name'].mean(), 2))
На пользователя в среднем приходится событий: 32.28
print('Минимальная дата:', data['date'].min())
print('Максимальная дата:', data['date'].max())
print('Мы располагаем данными за период:', data['date'].max().toordinal()-data['date'].min().toordinal() + 1, 'дней')
Минимальная дата: 2019-07-25 Максимальная дата: 2019-08-07 Мы располагаем данными за период: 14 дней
Построим гистограмму по дате-времени.
data['event_time'].hist(bins=(data['date'].max().toordinal()-data['date'].min().toordinal() + 1)*24, figsize=(13, 5))
plt.xticks(rotation=45)
plt.title('График распределения событий по дате-времени')
plt.xlabel('Дата')
plt.ylabel('Количество событий');
Вывод
Мы не можем быть уверены, что у вас одинаково полные данные за весь период. Технически в логи новых дней по некоторым пользователям могут «доезжать» события из прошлого — это может «перекашивать данные». По гистограмме можем увидеть, что наши данные становятся полными начиная с 2019-08-01. Скорее всего, временной лаг составляет одну неделю.
2019-08-01¶# сохраним первоначальные данные в переменной data_old
data_old = data
# удаляем даты до '2019-08-01'
data = data.query("event_time >= '2019-08-01'")
# заново строим гистограмму для проверки
data['event_time'].hist(bins=(data['date'].max().toordinal()-data['date'].min().toordinal() + 1)*24, figsize=(13, 5))
plt.xticks(rotation=45)
plt.title('График распределения событий по дате-времени')
plt.xlabel('Дата')
plt.ylabel('Количество событий');
print('Мы удалили', len(data_old)-len(data), 'наблюдения из первоначальных', len(data_old), 'наблюдений.')
print('Это составляет', round( 100*(len(data_old)-len(data))/len(data_old), 2), '% от первоначальных данных.')
print('-----')
print('Мы удалили',
data_old['user_id'].nunique() - data['user_id'].nunique(),
'пользователей из первоначальных',
data_old['user_id'].nunique(),
'пользователей.')
print('Это составляет',
round( ( ( data_old['user_id'].nunique() - data['user_id'].nunique() ) / data_old['user_id'].nunique() ) * 100, 2),
'% от первоначальных данных.')
Мы удалили 2826 наблюдения из первоначальных 243713 наблюдений. Это составляет 1.16 % от первоначальных данных. ----- Мы удалили 17 пользователей из первоначальных 7551 пользователей. Это составляет 0.23 % от первоначальных данных.
Как можем заметить, мы потеряли менее 2% наблюдений и менее 1% уникальных пользователей, отбросив данные ранее 2019-08-01.
Вывод
Мы отбросили неполные периоды, теперь по гистограмме дате и времени у нас отсутствуют аномалии. Мы сохранили данные с 2019-08-01, но, скорее всего, эти данные на самом деле относятся к периоду недельной давности (недельный лаг связан с тем, что технически в логи новых дней по некоторым пользователям могут «доезжать» события из прошлого — это может «перекашивать данные»).
pd.pivot_table(data, index='exp_id', values='user_id', aggfunc=['nunique', 'count'])
| nunique | count | |
|---|---|---|
| user_id | user_id | |
| exp_id | ||
| 246 | 2484 | 79302 |
| 247 | 2513 | 77022 |
| 248 | 2537 | 84563 |
Вывод
В каждой экспериментальной группе было более 2400 уникальных пользователей, и каждая экспериментальная группа содержит более 77 тыс наблюдений.
data.groupby('event_name')['user_id'].count().sort_values(ascending=False).reset_index()
| event_name | user_id | |
|---|---|---|
| 0 | MainScreenAppear | 117328 |
| 1 | OffersScreenAppear | 46333 |
| 2 | CartScreenAppear | 42303 |
| 3 | PaymentScreenSuccessful | 33918 |
| 4 | Tutorial | 1005 |
Вывод
В логах имеется 5 событий, чаще всего встречается событие MainScreenAppear.
event_table = data.groupby('event_name')['user_id'].nunique().sort_values(ascending=False).reset_index()
event_table.columns = ['event_name', 'user_nunique']
event_table['user_share'] = event_table['user_nunique'] / data['user_id'].nunique()
event_table
| event_name | user_nunique | user_share | |
|---|---|---|---|
| 0 | MainScreenAppear | 7419 | 0.984736 |
| 1 | OffersScreenAppear | 4593 | 0.609636 |
| 2 | CartScreenAppear | 3734 | 0.495620 |
| 3 | PaymentScreenSuccessful | 3539 | 0.469737 |
| 4 | Tutorial | 840 | 0.111495 |
Вывод
Среди 5 событий больше всего уникальных пользователей совершали событие MainScreenAppear (7419 событий, или 98.4% всех пользователей).
Предположим, в каком порядке происходят события
Скорее всего, события происходят в таком порядке:
Tutorial (руководство) - не все пользователи его читают, поэтому доля пользователей всего 11%MainScreenAppear (появляется главный экран) - через этот экран прошли 98% пользователейOffersScreenAppear (появляется экран с предложениями) - через этот экран прошли 60.9% пользователейCartScreenAppear (появляется экран с корзиной) - через этот экран прошли 49.6% пользователейPaymentScreenSuccessful (экран оплата прошла успешно) - через этот экран прошли 47% пользователейВсе события выстраиваются в последовательную цепочку.
Скорее всего, экран Tutorial не является обязательным для попадания на следующий экран, поэтому при расчете воронки не будем использовать это событие.
# рассчитываем долю пользователей
event_table = event_table.assign(user_percent = lambda x: (x['user_nunique'] / x['user_nunique'].shift(fill_value=7419))*100)
# округлим выводимые данные до сотых
event_table['user_share'] = event_table['user_share'].round(2)
event_table['user_percent'] = event_table['user_percent'].round(2)
# удаляем строку с событием 'Tutorial' и выводим на экран
event_table.drop(index=4)
| event_name | user_nunique | user_share | user_percent | |
|---|---|---|---|---|
| 0 | MainScreenAppear | 7419 | 0.98 | 100.00 |
| 1 | OffersScreenAppear | 4593 | 0.61 | 61.91 |
| 2 | CartScreenAppear | 3734 | 0.50 | 81.30 |
| 3 | PaymentScreenSuccessful | 3539 | 0.47 | 94.78 |
Визуализируем воронку.
fig = px.funnel(event_table, x='user_nunique', y='event_name')
fig.update_layout(title='Воронка продаж')
fig.update_yaxes(title=None)
fig.show()
print('Из экрана MainScreenAppear на следующий экран OffersScreenAppear не перешли', event_table.loc[0, 'user_percent'] - event_table.loc[1, 'user_percent'], '% пользователей.')
Из экрана MainScreenAppear на следующий экран OffersScreenAppear не перешли 38.09 % пользователей.
print('От первого события до оплаты доходит', round(
(min(event_table.drop(index=4)['user_nunique']) / max(event_table.drop(index=4)['user_nunique']) * 100), 2), \
'% пользователей')
От первого события до оплаты доходит 47.7 % пользователей
Вывод
MainScreenAppear - из этого экрана на следующий экран OffersScreenAppear не перешли 38.09% пользователей.exp_table = data.groupby('exp_id')['user_id'].nunique().sort_values(ascending=False).reset_index()
exp_table.columns = ['exp_id', 'user_nunique']
exp_table
| exp_id | user_nunique | |
|---|---|---|
| 0 | 248 | 2537 |
| 1 | 247 | 2513 |
| 2 | 246 | 2484 |
Вывод
Для проверки разницы между выборками 246 и 247 (А/А-эксперимент) будем использовать z-критерий:
Сформулируем гипотезы:
Нулевая гипотеза: в проверяемых группах отсутствуют различия в доле пользователей, совершивших выбранное событие
Альтернативная гипотеза: в проверяемых группах отличается доля пользователей, совершивших выбранное событие
Гипотезы проверием с помощью z-критерия. Пропишем универсальную функцию check_hypothesis для проведения теста с использованием z-критерия.
# Зададим универсальную функцию для проведения теста с использованием z-критерия
def check_hypothesis(successes1, successes2, trials1, trials2, alpha=0.01):
# пропорция успехов в первой группе
p1 = successes1/trials1
# пропорция успехов во второй группе
p2 = successes2/trials2
# пропорция успехов в комбинированном датасете
p_combined = (successes1 + successes2) / (trials1 + trials2)
# разница пропорций в датасетах
difference = p1 - p2
# считаем статистику в ст.отклонениях стандартного нормального распределения
z_value = difference / np.sqrt(p_combined * (1 - p_combined) * (1/trials1 + 1/trials2))
# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
distr = stats.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('P-значение:', round(p_value, 2))
print('Уровень значимости:', alpha)
if (p_value < alpha): print("Отвергаем нулевую гипотезу: между долями есть значимая разница.")
else: print("Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.")
Пропишем цикл для расчета z-критерия для всех событий из таблицы event_table. Проанализируем результаты.
# задаем цикл
for event in event_table['event_name']:
# фильтруем по event
data_event = data.query('event_name == @event')
# группируем по event
exp_event = data_event.groupby('exp_id')['user_id'].nunique().sort_values(ascending=False)
# показываем выбранный event на экране
print(event)
# применяем функцию check_hypothesis для расчета z-критерия
check_hypothesis(
successes1 = exp_event.loc[246],
successes2 = exp_event.loc[247],
trials1 = data.query('exp_id == 246')['user_id'].nunique(),
trials2 = data.query('exp_id == 247')['user_id'].nunique(),
alpha=0.01)
print('----')
MainScreenAppear P-значение: 0.76 Уровень значимости: 0.01 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными. ---- OffersScreenAppear P-значение: 0.25 Уровень значимости: 0.01 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными. ---- CartScreenAppear P-значение: 0.23 Уровень значимости: 0.01 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными. ---- PaymentScreenSuccessful P-значение: 0.11 Уровень значимости: 0.01 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными. ---- Tutorial P-значение: 0.94 Уровень значимости: 0.01 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными. ----
Вывод
Пропишем цикл для расчета z-критерия для всех событий из таблицы event_table для группы 246 и 248.
# задаем цикл
for event in event_table['event_name']:
# фильтруем по event
data_event = data.query('event_name == @event')
# группируем по event
exp_event = data_event.groupby('exp_id')['user_id'].nunique().sort_values(ascending=False)
# показываем выбранный event на экране
print(event)
# применяем функцию check_hypothesis для расчета z-критерия
check_hypothesis(
successes1 = exp_event.loc[246],
successes2 = exp_event.loc[248],
trials1 = data.query('exp_id == 246')['user_id'].nunique(),
trials2 = data.query('exp_id == 248')['user_id'].nunique(),
alpha=0.01)
print('----')
MainScreenAppear P-значение: 0.29 Уровень значимости: 0.01 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными. ---- OffersScreenAppear P-значение: 0.21 Уровень значимости: 0.01 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными. ---- CartScreenAppear P-значение: 0.08 Уровень значимости: 0.01 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными. ---- PaymentScreenSuccessful P-значение: 0.21 Уровень значимости: 0.01 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными. ---- Tutorial P-значение: 0.83 Уровень значимости: 0.01 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными. ----
Пропишем цикл для расчета z-критерия для всех событий из таблицы event_table для группы 247 и 248.
# задаем цикл
for event in event_table['event_name']:
# фильтруем по event
data_event = data.query('event_name == @event')
# группируем по event
exp_event = data_event.groupby('exp_id')['user_id'].nunique().sort_values(ascending=False)
# показываем выбранный event на экране
print(event)
# применяем функцию check_hypothesis для расчета z-критерия
check_hypothesis(
successes1 = exp_event.loc[247],
successes2 = exp_event.loc[248],
trials1 = data.query('exp_id == 247')['user_id'].nunique(),
trials2 = data.query('exp_id == 248')['user_id'].nunique(),
alpha=0.01)
print('----')
MainScreenAppear P-значение: 0.46 Уровень значимости: 0.01 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными. ---- OffersScreenAppear P-значение: 0.92 Уровень значимости: 0.01 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными. ---- CartScreenAppear P-значение: 0.58 Уровень значимости: 0.01 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными. ---- PaymentScreenSuccessful P-значение: 0.74 Уровень значимости: 0.01 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными. ---- Tutorial P-значение: 0.77 Уровень значимости: 0.01 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными. ----
Пропишем цикл для расчета z-критерия для всех событий из таблицы event_table для группы 246+247 и 248.
# задаем цикл
for event in event_table['event_name']:
# фильтруем по event
data_event = data.query('event_name == @event')
# группируем по event
exp_event = data_event.groupby('exp_id')['user_id'].nunique().sort_values(ascending=False)
# показываем выбранный event на экране
print(event)
# применяем функцию check_hypothesis для расчета z-критерия
check_hypothesis(
successes1 = exp_event.loc[246] + exp_event.loc[247],
successes2 = exp_event.loc[248],
trials1 = data.query('exp_id == 246 or exp_id == 247')['user_id'].nunique(),
trials2 = data.query('exp_id == 248')['user_id'].nunique(),
alpha=0.01)
print('----')
MainScreenAppear P-значение: 0.29 Уровень значимости: 0.01 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными. ---- OffersScreenAppear P-значение: 0.43 Уровень значимости: 0.01 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными. ---- CartScreenAppear P-значение: 0.18 Уровень значимости: 0.01 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными. ---- PaymentScreenSuccessful P-значение: 0.6 Уровень значимости: 0.01 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными. ---- Tutorial P-значение: 0.76 Уровень значимости: 0.01 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными. ----
Вывод
246 vs 248, 247 vs 248, 246+247 vs 248 не отвергается, следовательно, между контрольными группами и группой с изменённым шрифтом отсутствуют статистические различия на уровне значимости 1%Примечание
мы сделали 20 попарных сравнений (5 событий по 4 сравнения) для проверки одной нулевой гипотезы, что между группами А/А/B нет значимых различий
чем больше сравнений подгрупп в тесте, тем тем выше вероятность получения хотя бы одного ложноположительного результата (ошибка первого рода) - это когда по результату статистического теста отвергнута верная нулевая гипотеза
когда используется уровень значимости 1%, то мы соглашаемся с тем, что в 1% случаев мы будем ошибаться
в нашем случае, используя 1% уровень значимости, мы могли совершенно случайно получить ложное заключение в 1 из 100 тестов
так как мы использовали 20 попарных сравнений подгрупп, вероятность получить ложноположительный результат возрастает до 18,2% (1 - 0,99^20)
стоит отметить, что во всех наших тестах для подгрупп p-value был намного выше 1%, и ни разу нулевая гипотеза не была отвергнута, следовательно, необходимости в корректировке выбранного уровня значимости нет
В рамках этого исследования мы выявили следующее
Больше всего пользователей теряется на главном экране приложения MainScreenAppear - из этого экрана на экран с предложениями OffersScreenAppear не перешли 38% пользователей. От главного экрана MainScreenAppear до успешного совершения оплаты продукта доходят 47.7% пользователей.
Первый экран с руководством Tutorial читает всего 11% пользователей.
Результаты А/А/В-эксперимента показали отсутствие статистических различий на 1% уровне значимости между контрольными группами и группой с изменённым шрифтом.
Следовательно, для повышения эффективности продаж в мобильном приложении можно улучшить главный экран MainScreenAppear. Менять шрифты в приложении смысла нет, так как в поведении пользователей не было выявлено статистически значимых отличий.